nwc - nostr wallet connect

2025-08-28 · 5 min read

Nostr Wallet Connect (NWC) allows clients to access a remote lightning wallet via a standardized protocol.

For Lexe, users will export a NWC connect URI from their wallet (effectively another form of client credentials). They can specify which permissions and capabilities this client has. You can even do limited pull-payments with this, exporting a client that can only pay invoices up to a certain total amount.

see also #

connect flow (copy+paste) #

  1. User opens Lexe wallet app and exports a NWC connection string URI with selected permissions.
  2. (?) user node publishes a NIP47 info replaceable event to the Lexe nostr relay.
  3. User pastes this connection string URI into a third-party client app.
  4. The client app connects to the Lexe nostr relay (contained in the URI) and requests the info event for the user's pubkey (also in the URI). This info event describes the permissions and capabilities granted to this client.

request flow #

  1. The client creates e.g. a pay_invoice request event, encrypts it with the derived secret, and sends the NIP47 request event to the Lexe nostr relay (in the URI).

  2. The Lexe nostr relay sees the node's wallet-pubkey in the event, requests the user node to run (if not already running), and queues the event in the meantime.

  3. (if just starting up) The user node connects to the Lexe nostr relay via websockets and subscribes to all events for ["p", "<wallet-pubkey>"], which also returns all immediately queued events.

    (otherwise, while running) The user node gets a new event pushed from the relay via its websocket connection.

  4. The user node decrypts+validates the event, handles the command, creates a NIP47 response event for this client, encrypts it, and sends it to the Lexe nostr relay.

  5. The Lexe nostr relay pushes the NIP47 response event to the client app via its websocket connection.

  6. The client decrypts+validates the response event and processes the result.

connect URI #

nostr+walletconnect://<wallet-pubkey>
  ?relay=<uri-encode("wss://nostr.lexe.app")>
  &secret=<client-secret>
  &lud16=<uri-encode(lud16-lightning-address)>
  • <wallet-pubkey>: pubkey that identifies the wallet service
    • format: hex-encoded secp256k1 X-only public key (32B, 64B hex)
    • ex: 540949885e02d8d6715170b23f3e790146bbaef0f0a4ef8f4324780de63ec04b
  • <relay>: Lexe nostr relay URL
    • ex: wss://nostr.lexe.app
  • <client-secret>: the secp256k1 secret key the client should use
    • The shared secret used to encrypt/decrypt requests and responses is derived from ECDH between <client-secret> and <pubkey>.
    • format: hex-encoded secpk256k1 secret key (32B, 64B hex)
    • ex: a723ca78e7f2039b45a8f2401a4b697be700d374f25dc88c58b309f14b4a51aa
  • <lud16>: (optional) lightning address associated with this wallet
    • ex: philip@lexe.app

how the keys work #

The wallet generates a new secp256k1 keypair for each client and includes the client secret key in the URI and saves the corresponding client pubkey, which it uses to identify requests from this client.

wallet: shared-secret = ECDH(wallet-secret, client-pubkey) client: shared-secret = ECDH(client-secret, wallet-pubkey)

nostr events #

info event #

{
    "id": "<sha256(event-data)>",
    "pubkey": "<wallet-pubkey>",
    "created_at": 1800000000, // unix timestamp in seconds
    "kind": 13194,
    "tags": [
        // List of supported encryption schemes as described in the Encryption section.
        ["encryption", "nip44_v2 nip04"],
        ["notifications", "payment_received payment_sent"]
        // ...
    ],
    "content": "pay_invoice get_balance make_invoice lookup_invoice list_transactions get_info notifications",
    "sig": "<wallet-signature>"
}

This is a replaceable event, i.e., the relay stores the latest event per (pubkey, kind) pair.

request event #

{
    "id": "<sha256(event-data)>",
    "pubkey": "<client-pubkey>",
    "created_at": 1800000000, // unix timestamp in seconds
    "kind" 23194,
    "tags": [
        ["encryption", "nip44_v2"],
        // public key of the wallet service.
        ["p", "03..."]
        // optional expiration time
        ["expiration", "1756423549"]
        // ...
    ],
    // Encryption type corresponds to the `encryption` tag.
    "content": nip44_encrypt({
        // method, string
        "method": "pay_invoice",
        // params, object
        "params": {
            // command-related data
            "invoice": "lnbc50n1..."
        }
    }),
    "sig": "<client-signature>"
}

response event #

{
    "id": "<sha256(event-data)>",
    "pubkey": "<wallet-pubkey>",
    "created_at": 1800000000, // unix timestamp in seconds
    "kind" 23195,
    "tags": [
        // public key of the requesting client app
        ["p", "<client-pubkey>"],
        // id of the request event this is responding to
        ["e", "1234"]
        // ...
    ],
    // Encrypted using the scheme requested by the client.
    "content": nip44_encrypt({
        // indicates the structure of the result field
        "result_type": "pay_invoice",
        // object, non-null in case of error
        "error": {
            // string error code, see below
            "code": "UNAUTHORIZED",
            "message": "human readable error message"
        },
        // result, object. null in case of error.
        "result": {
            // command-related data
            "preimage": "0123456789abcdef..."
        }
    }),
    "sig": "<wallet-signature>"
}

notification event #

{
    "id": "<sha256(event-data)>",
    "pubkey": "<wallet-pubkey>",
    "created_at": 1800000000, // unix timestamp in seconds
    "kind": 23197,
    "tags": [
        ["encryption", "nip44_v2"],
        // public key of the requesting client app
        ["p", "<client-pubkey>"]
        // ...
    ],
    // Encryption type corresponds to the `encryption` tag.
    "content": nip44_encrypt({
        // indicates the structure of the notification field
        "notification_type": "payment_received",
        "notification": {
            // notification-related data
            "payment_hash": "0123456789abcdef..."
        }
    }),
    "sig": "<wallet-signature>"
}

methods #

  • pay_invoice
  • multi_pay_invoice
  • pay_keysend
  • multi_pay_keysend
  • make_invoice
  • lookup_invoice
  • list_transactions
  • get_balance
  • get_info

notifications #

  • payment_received
  • payment_sent

design notes #

how do clients auth to our relay? is it necessary? #

only generate one wallet keypair or one per client? #

Note: Using a single wallet service key for all connections is simpler but unfortunately it leaks metadata (anyone who knows the wallet service pubkey can filter events by that key to see all activity through that wallet service). For this reason in Alby Hub we now generate unique wallet service keys per connection.

@rolznz - https://github.com/nostr-protocol/nips/pull/1818#issuecomment-2699760096

isolated clients #

Alby allows clients marked as isolated (called "subwallets" in UI), which have their own balance and transaction history.

appendix #

NIP-01 - events and signatures #

Each user has a secp256k1 schnorr keypair (32B secret, 32B X-only pubkey).

An event is:

{
  "id": "<32-bytes lowercase hex-encoded sha256 of the serialized event data>",
  "pubkey": "<32-bytes lowercase hex-encoded public key of the event creator>",
  "created_at": 1756423549, // unix timestamp in seconds
  "kind": 23194, // integer between 0 and 65535
  "tags": [
    ["<arbitrary string>", /* ... */],
    // ...
  ],
  "content": "<arbitrary string>",
  "sig": "<64-bytes lowercase hex of the signature of the sha256 hash of the serialized event data, which is the same as the 'id' field>"
}

The event is canonicalized for e.g. computing the id and sig fields by serializing as a JSON array:

[
  0,
  <pubkey, as a lowercase hex string>,
  <created_at, as a number>,
  <kind, as a number>,
  <tags, as an array of arrays of non-null strings>,
  <content, as a string>
]

Event kinds:

ClassRangesStorage
Regular1000-9999, 4-44, 1, 2All events stored
Replaceable10000-19999, 0, 3Latest per (pubkey, kind) stored
Ephemeral20000-29999Not stored
Addressable30000-39999Latest per (pubkey, kind, d-tag)

Clients open websocket connections to relays and send/receive events.

Clients send messages to relays like:

  • ["EVENT", <event JSON as defined above>]
  • ["REQ", <subscription id>, <filter JSON>, ...]
  • ["CLOSE", <subscription id>]

Filters look like:

{
  "ids": [/* <a list of event ids> */],
  "authors": [/* <a list of lowercase pubkeys, the pubkey of an event must be one of these> */],
  "kinds": [/* <a list of a kind numbers> */],
  "#<single-letter (a-zA-Z)>": [/* <a list of tag values, for #e — a list of event ids, for #p — a list of pubkeys, etc.> */],
  "since": 1756420000, // <Events must have a created_at >= to this to pass>
  "until": 1756480000, // <Events must have a created_at <= to this to pass>,
  "limit": 100, // <maximum number of events relays SHOULD return in the initial query>
}

Relays send messages to clients like:

  • ["EVENT", <subscription id>, <event JSON as defined above>]
  • ["OK", <event id>, <true|false>, <message>] ACK or NACK EVENT message
  • ["EOSE", <subscription id>] end of stored events
  • ["CLOSED", <subscription id>, <message>] subscription closed by the relay
  • ["NOTICE", <message>] a human-readable message from the relay

alby NWC HTTP bridge API #

Site: https://guides.getalby.com/developer-guide/nostr-wallet-connect-api/building-lightning-apps/nwc-http-api

Integrate with NWC relay servers via simple HTTP API. Subscribe to events and notifications via webhooks.

Looks like it might also work for non-Alby NWC relays?

alby hub nip47 #

See: https://github.com/getAlby/hub/tree/master/nip47